page.tsx 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814
  1. 'use client';
  2. import './style.scss';
  3. import { use, useCallback, useEffect, useState } from 'react';
  4. import Link from 'next/link';
  5. import { useRouter, useSearchParams } from 'next/navigation';
  6. import { CreditCard, ExternalLink, Gamepad2, Package, ShoppingCart, Ticket } from 'lucide-react';
  7. import { fetchApi } from '@/lib/utils/client';
  8. import Loading from '@/app/component/Loading';
  9. import PayButton, { type PayButtonState } from '@/app/component/PayButton';
  10. import useAuth from '@/hooks/useAuth';
  11. import useCart from '@/hooks/useCart';
  12. import type {
  13. ProductDetail,
  14. ProductType,
  15. ChannelSearchResponse,
  16. ChannelSearchRow
  17. } from '@/types/store';
  18. /** 최근 후원 채널 localStorage MRU. 최신 사용 순(newest first), 최대 5개. */
  19. const RECENT_KEY = 'antooza:recent-donation-channels';
  20. const RECENT_MAX = 5;
  21. function loadRecent(): ChannelSearchRow[]
  22. {
  23. if (typeof window === 'undefined') {
  24. return [];
  25. }
  26. try {
  27. const raw = localStorage.getItem(RECENT_KEY);
  28. return raw ? JSON.parse(raw) as ChannelSearchRow[] : [];
  29. }
  30. catch {
  31. return [];
  32. }
  33. }
  34. function saveRecent(list: ChannelSearchRow[])
  35. {
  36. if (typeof window === 'undefined') {
  37. return;
  38. }
  39. localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, RECENT_MAX)));
  40. }
  41. function addRecent(ch: ChannelSearchRow)
  42. {
  43. const cur = loadRecent().filter(c => c.id !== ch.id);
  44. saveRecent([ch, ...cur]);
  45. }
  46. function removeRecent(id: number)
  47. {
  48. saveRecent(loadRecent().filter(c => c.id !== id));
  49. }
  50. const TYPE_BADGE_CLASS: Record<ProductType, string> = {
  51. 1: 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300',
  52. 2: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'
  53. };
  54. const TYPE_LABEL: Record<ProductType, string> = { 1: '실물', 2: '쿠폰' };
  55. function TypeBadge({ type, size = 'sm' }: { type: ProductType; size?: 'sm'|'md' })
  56. {
  57. const Icon = type === 2 ? Ticket : Package;
  58. const padCls = size === 'md' ? 'p-1.5' : 'p-1';
  59. const iconCls = size === 'md' ? 'size-3.5' : 'size-3';
  60. return (
  61. <span
  62. title={TYPE_LABEL[type]}
  63. aria-label={TYPE_LABEL[type]}
  64. className={`inline-flex items-center justify-center rounded ${padCls} ${TYPE_BADGE_CLASS[type]}`}
  65. >
  66. <Icon className={iconCls} />
  67. </span>
  68. );
  69. }
  70. const HARD_MAX = 999;
  71. function formatPrice(n: number): string
  72. {
  73. return n.toLocaleString('ko-KR');
  74. }
  75. function clampQuantity(value: number, stock: number, minPurchase: number, maxPurchase: number): number
  76. {
  77. const stockCap = stock > 0 ? Math.min(stock, HARD_MAX) : HARD_MAX;
  78. const upper = Math.min(maxPurchase, stockCap);
  79. return Math.max(minPurchase, Math.min(value, upper));
  80. }
  81. export default function StoreDetailPage({ params }: { params: Promise<{ id: string }> })
  82. {
  83. const { id } = use(params);
  84. const productID = parseInt(id, 10);
  85. const router = useRouter();
  86. const searchParams = useSearchParams();
  87. const codeParam = searchParams.get('code');
  88. const { loginCheck } = useAuth();
  89. const { addItem, setChannel } = useCart();
  90. const [product, setProduct] = useState<ProductDetail|null>(null);
  91. const [loading, setLoading] = useState(true);
  92. const [error, setError] = useState<string|null>(null);
  93. const [quantity, setQuantity] = useState(1);
  94. // 상품 로드 후 초기 수량을 minPurchase로 세팅
  95. useEffect(() => {
  96. if (product) {
  97. setQuantity(product.minPurchase);
  98. setOptedOut(false); // 상품 전환 시 이전 상품의 선택안함 상태 잔존 방지
  99. }
  100. }, [product]);
  101. const [selectedChannel, setSelectedChannel] = useState<ChannelSearchRow|null>(null);
  102. const [optedOut, setOptedOut] = useState(false);
  103. const [channelModalOpen, setChannelModalOpen] = useState(false);
  104. const [pendingPurchase, setPendingPurchase] = useState(false);
  105. const [payState] = useState<PayButtonState>('idle');
  106. const [submitError, setSubmitError] = useState<string|null>(null);
  107. const [tab, setTab] = useState<'product'|'game'>('product');
  108. const load = useCallback(async () => {
  109. setLoading(true);
  110. const res = await fetchApi<ProductDetail>(`/api/store/products/${productID}`, { silent: true });
  111. if (res.success && res.data) {
  112. setProduct(res.data);
  113. setError(null);
  114. }
  115. else {
  116. setError(res.message || '상품을 불러올 수 없습니다.');
  117. }
  118. setLoading(false);
  119. }, [productID]);
  120. useEffect(() => {
  121. load();
  122. }, [load]);
  123. // querystring ?code=XXX → 후원 채널 자동 설정 (case-insensitive 검증, 무효 시 조용히 무시)
  124. useEffect(() => {
  125. if (!codeParam) {
  126. return;
  127. }
  128. let cancelled = false;
  129. (async () => {
  130. const res = await fetchApi<ChannelSearchRow>(`/api/store/channels/by-code?code=${encodeURIComponent(codeParam)}`, { silent: true });
  131. if (cancelled) {
  132. return;
  133. }
  134. if (res.success && res.data) {
  135. setSelectedChannel(res.data);
  136. addRecent(res.data);
  137. }
  138. })();
  139. return () => { cancelled = true; };
  140. }, [codeParam]);
  141. const proceedPurchase = (channel: ChannelSearchRow|null) => {
  142. if (!product) {
  143. return;
  144. }
  145. // 바로 구매: 현재 선택 상품을 cart 에 add 후 /checkout 으로 이동.
  146. // 기존 cart 항목은 보존되며 함께 결제됨.
  147. addItem({
  148. productID: product.id,
  149. name: product.name,
  150. thumbnail: product.thumbnail,
  151. price: product.salePrice,
  152. type: product.type,
  153. gameName: product.gameKorName,
  154. stock: product.stock,
  155. requireDonationChannel: product.requireDonationChannel,
  156. quantity
  157. });
  158. setChannel(channel);
  159. router.push('/checkout');
  160. };
  161. const handlePurchase = () => {
  162. if (!loginCheck()) {
  163. return;
  164. }
  165. if (!product) {
  166. return;
  167. }
  168. if (quantity < 1) {
  169. setSubmitError('수량은 1 이상이어야 합니다.');
  170. return;
  171. }
  172. setSubmitError(null);
  173. // 후원 채널 확인 절차 강제 — 채널 미선택 시 (필수 상품이거나 선택안함 미체크면) 모달을 먼저 연다.
  174. if (!selectedChannel && (product.requireDonationChannel || !optedOut)) {
  175. setPendingPurchase(true);
  176. setChannelModalOpen(true);
  177. return;
  178. }
  179. proceedPurchase(selectedChannel);
  180. };
  181. const handleAddToCart = () => {
  182. if (!product) {
  183. return;
  184. }
  185. addItem({
  186. productID: product.id,
  187. name: product.name,
  188. thumbnail: product.thumbnail,
  189. price: product.salePrice,
  190. type: product.type,
  191. gameName: product.gameKorName,
  192. stock: product.stock,
  193. requireDonationChannel: product.requireDonationChannel,
  194. quantity
  195. });
  196. setSubmitError(null);
  197. // 작은 피드백 — 차후 toast 시스템으로 교체 가능
  198. alert('장바구니에 담았습니다.');
  199. };
  200. if (loading) {
  201. return <Loading />;
  202. }
  203. if (error || !product) {
  204. return (
  205. <div className='container mx-auto px-4 py-12 text-center'>
  206. <p className='text-red-600 mb-4'>{error || '상품을 찾을 수 없습니다.'}</p>
  207. <Link href='/store' className='text-blue-600 underline'>상점으로 돌아가기</Link>
  208. </div>
  209. );
  210. }
  211. const unitPrice = product.salePrice; // 할인 반영된 단가
  212. const total = unitPrice * quantity;
  213. const stockLabel = product.stock === -1 ? '' : `재고 ${product.stock}개`;
  214. const hasDiscount = product.discountType !== 0 && product.salePrice !== product.price;
  215. return (
  216. <div id='store-detail' className='container mx-auto max-w-5xl px-4 py-6'>
  217. {/* breadcrumb — 모두 파랑 계열 */}
  218. <nav className='text-sm mb-4 flex flex-wrap items-center gap-1 text-blue-600 dark:text-blue-400'>
  219. <Link href='/store' className='hover:underline'>상점</Link>
  220. <span className='text-neutral-400 dark:text-neutral-600 text-xl leading-none mx-0.5'>›</span>
  221. <Link href={`/store?gameID=${product.gameID}`} className='hover:underline'>
  222. {product.gameKorName}
  223. </Link>
  224. <span className='text-neutral-400 dark:text-neutral-600 text-xl leading-none mx-0.5'>›</span>
  225. <span className='truncate'>{product.name}</span>
  226. </nav>
  227. <div className='grid grid-cols-1 md:grid-cols-[360px_1fr] gap-6 lg:gap-10'>
  228. {/* 좌측 — 상품 이미지 (작게) */}
  229. <div>
  230. <div className='aspect-square bg-neutral-100 dark:bg-neutral-800 rounded-lg overflow-hidden flex items-center justify-center border border-neutral-200 dark:border-neutral-800 sticky md:top-4'>
  231. {product.thumbnail ? (
  232. // eslint-disable-next-line @next/next/no-img-element
  233. <img src={product.thumbnail} alt={product.name} className='w-full h-full max-w-[calc(100%/1.6)] max-h-[calc(100%/1.6)]' />
  234. ) : (
  235. <span className='text-neutral-400'>이미지 없음</span>
  236. )}
  237. </div>
  238. </div>
  239. {/* 우측 — 주문 영역 */}
  240. <div className='min-w-0'>
  241. <div className='flex items-center gap-2 mb-2'>
  242. <TypeBadge type={product.type} size='md' />
  243. <Gamepad2 className='size-4 shrink-0 text-purple-600 dark:text-purple-400' />
  244. <span className='text-sm font-semibold text-purple-600 dark:text-purple-400'>
  245. {product.gameKorName}
  246. </span>
  247. {product.gameEngName && (
  248. <span className='text-xs text-neutral-400'>({product.gameEngName})</span>
  249. )}
  250. </div>
  251. <h1 className='text-2xl md:text-3xl font-bold mb-2 break-keep'>{product.name}</h1>
  252. <div className='text-xs text-neutral-500 mb-4'>
  253. {product.gameLink ? (
  254. <a
  255. href={product.gameLink}
  256. target='_blank'
  257. rel='noopener noreferrer'
  258. className='inline-flex items-center gap-1 hover:underline hover:text-neutral-700 dark:hover:text-neutral-300'
  259. >
  260. {product.gamePublisher}
  261. <ExternalLink className='size-3' aria-hidden />
  262. </a>
  263. ) : (
  264. product.gamePublisher
  265. )}
  266. </div>
  267. <div className='border-t border-b border-neutral-200 dark:border-neutral-800 py-4 mb-4'>
  268. <div className='text-3xl md:text-4xl font-extrabold text-red-600 dark:text-red-400'>
  269. {formatPrice(unitPrice)}P
  270. </div>
  271. {hasDiscount && (
  272. <div className='flex items-baseline gap-2 mt-1'>
  273. <span className='text-sm line-through text-yellow-600 dark:text-yellow-400'>
  274. {formatPrice(product.price)}P
  275. </span>
  276. <span className='text-sm font-semibold text-green-600 dark:text-green-400'>
  277. {product.discountType === 1 ? `-${product.discountValue}%` : `-${formatPrice(product.discountValue)}P`}
  278. </span>
  279. </div>
  280. )}
  281. {product.stock !== -1 && (
  282. <div className='text-xs text-neutral-500 mt-1'>{stockLabel}</div>
  283. )}
  284. </div>
  285. <div className='space-y-4 mb-4'>
  286. <div className='flex items-center gap-4'>
  287. <label className='text-sm font-semibold w-20 shrink-0'>수량</label>
  288. <div className='flex items-stretch border border-neutral-300 dark:border-neutral-700 rounded overflow-hidden'>
  289. <button
  290. type='button'
  291. onClick={() => { setQuantity(q => clampQuantity(q - 1, product.stock, product.minPurchase, product.maxPurchase)); }}
  292. className='px-3 hover:bg-neutral-100 dark:hover:bg-neutral-800'
  293. aria-label='수량 감소'
  294. >
  295. -
  296. </button>
  297. <input
  298. title={`${quantity}개`}
  299. type='number'
  300. min={product.minPurchase}
  301. max={Math.min(product.maxPurchase, product.stock > 0 ? product.stock : HARD_MAX)}
  302. value={quantity}
  303. onChange={(e) => {
  304. const v = parseInt(e.target.value || '1', 10);
  305. setQuantity(clampQuantity(isNaN(v) ? product.minPurchase : v, product.stock, product.minPurchase, product.maxPurchase));
  306. }}
  307. onBlur={(e) => {
  308. const v = parseInt(e.target.value || '1', 10);
  309. setQuantity(clampQuantity(isNaN(v) ? product.minPurchase : v, product.stock, product.minPurchase, product.maxPurchase));
  310. }}
  311. className='w-14 text-center bg-white dark:bg-neutral-900 outline-none border-0 appearance-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none'
  312. />
  313. <button
  314. type='button'
  315. onClick={() => { setQuantity(q => clampQuantity(q + 1, product.stock, product.minPurchase, product.maxPurchase)); }}
  316. className='px-3 hover:bg-neutral-100 dark:hover:bg-neutral-800'
  317. aria-label='수량 증가'
  318. >
  319. +
  320. </button>
  321. </div>
  322. <span className='text-xs text-neutral-500'>{product.minPurchase > 1 ? `최소 ${product.minPurchase}개 / ` : ''}최대 {product.maxPurchase}개</span>
  323. </div>
  324. <div className='flex items-center gap-4'>
  325. <label className='text-sm font-semibold w-20 shrink-0'>후원 채널</label>
  326. <button
  327. type='button'
  328. onClick={() => { if (loginCheck()) { setChannelModalOpen(true); } }}
  329. className='border border-neutral-300 dark:border-neutral-700 rounded px-3 py-2 text-sm flex-1 text-left bg-white dark:bg-neutral-900 hover:bg-neutral-50 dark:hover:bg-neutral-800 truncate'
  330. >
  331. {selectedChannel ? (
  332. <span className='font-semibold'>{selectedChannel.name}</span>
  333. ) : (
  334. <span className='text-neutral-400'>후원 채널 선택 (선택)</span>
  335. )}
  336. </button>
  337. {selectedChannel && (
  338. <button
  339. type='button'
  340. onClick={() => { setSelectedChannel(null); }}
  341. className='text-xs text-neutral-500 underline'
  342. >
  343. 제거
  344. </button>
  345. )}
  346. </div>
  347. </div>
  348. <div className='bg-neutral-100 dark:bg-neutral-800 rounded p-4 mb-4'>
  349. <div className='flex justify-between text-sm mb-2'>
  350. <span>상품 금액</span>
  351. <span>{formatPrice(unitPrice)}P × {quantity}</span>
  352. </div>
  353. <div className='flex justify-between font-bold text-lg pt-2 border-t border-neutral-300 dark:border-neutral-700'>
  354. <span>결제 금액</span>
  355. <span className='text-red-600 dark:text-red-400'>{formatPrice(total)}P</span>
  356. </div>
  357. </div>
  358. {submitError && (
  359. <div className='text-red-600 text-sm mb-2'>{submitError}</div>
  360. )}
  361. <div className='grid grid-cols-1 sm:grid-cols-[1fr_180px] gap-2 mb-3'>
  362. <PayButton
  363. state={payState}
  364. label={product.stock === 0 ? '품절' : '구매하기'}
  365. icon={product.stock === 0 ? null : <CreditCard className='size-5' aria-hidden />}
  366. disabled={product.stock === 0}
  367. onClick={handlePurchase}
  368. />
  369. <button
  370. type='button'
  371. disabled={product.stock === 0}
  372. onClick={handleAddToCart}
  373. className='h-[52px] rounded-md border-2 border-yellow-500 text-yellow-700 dark:border-purple-500 dark:text-purple-300 font-bold hover:bg-yellow-50 dark:hover:bg-purple-950/30 disabled:opacity-50 inline-flex items-center justify-center gap-2'
  374. >
  375. <ShoppingCart className='size-5' aria-hidden />
  376. 장바구니
  377. </button>
  378. </div>
  379. <p className='text-xs text-neutral-500'>
  380. * 결제 시 캐시 잔액에서 차감됩니다. (토큰 사용 불가)
  381. </p>
  382. </div>
  383. </div>
  384. {/* 하단 탭 — 상품설명 / 게임소개 */}
  385. <div className='mt-12'>
  386. <div className='border-b border-neutral-200 dark:border-neutral-800 flex gap-1'>
  387. <button
  388. type='button'
  389. onClick={() => { setTab('product'); }}
  390. className={`px-4 py-2 text-sm font-semibold border-b-2 -mb-px ${
  391. tab === 'product'
  392. ? 'border-blue-600 text-blue-600 dark:text-blue-400 dark:border-blue-400'
  393. : 'border-transparent text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
  394. }`}
  395. >
  396. 상품 설명
  397. </button>
  398. <button
  399. type='button'
  400. onClick={() => { setTab('game'); }}
  401. className={`px-4 py-2 text-sm font-semibold border-b-2 -mb-px ${
  402. tab === 'game'
  403. ? 'border-blue-600 text-blue-600 dark:text-blue-400 dark:border-blue-400'
  404. : 'border-transparent text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
  405. }`}
  406. >
  407. 게임 소개
  408. </button>
  409. </div>
  410. <div className='py-6'>
  411. {tab === 'product' && (
  412. product.description ? (
  413. <div
  414. className='product-description ck-content'
  415. dangerouslySetInnerHTML={{ __html: product.description }}
  416. />
  417. ) : (
  418. <p className='text-neutral-500 text-sm'>상품 설명이 없습니다.</p>
  419. )
  420. )}
  421. {tab === 'game' && (
  422. <div>
  423. {product.gameBigImage && (
  424. <div className='mb-4 rounded-lg overflow-hidden'>
  425. {/* eslint-disable-next-line @next/next/no-img-element */}
  426. <img src={product.gameBigImage} alt={product.gameKorName} className='w-full h-auto' />
  427. </div>
  428. )}
  429. <h3 className='text-xl font-bold mb-2'>
  430. {product.gameKorName}
  431. {product.gameEngName && (
  432. <span className='text-neutral-400 text-base ml-2'>({product.gameEngName})</span>
  433. )}
  434. </h3>
  435. <div className='text-sm text-neutral-500 mb-4'>{product.gamePublisher}</div>
  436. {product.gameDescription ? (
  437. <div
  438. className='game-description ck-content'
  439. dangerouslySetInnerHTML={{ __html: product.gameDescription }}
  440. />
  441. ) : (
  442. <p className='text-neutral-500 text-sm'>게임 소개가 없습니다.</p>
  443. )}
  444. {product.gameLink && (
  445. <a
  446. href={product.gameLink}
  447. target='_blank'
  448. rel='noopener noreferrer'
  449. className='inline-block mt-4 text-blue-600 dark:text-blue-400 hover:underline text-sm'
  450. >
  451. 공식 사이트 바로가기 →
  452. </a>
  453. )}
  454. </div>
  455. )}
  456. </div>
  457. </div>
  458. {/* 같은 게임 다른 상품 */}
  459. {product.relatedProducts.length > 0 && (
  460. <div className='mt-12 border-t border-neutral-200 dark:border-neutral-800 pt-8'>
  461. <h2 className='text-xl font-bold mb-4'>같은 게임의 다른 상품</h2>
  462. <div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4'>
  463. {product.relatedProducts.map((r) => (
  464. <Link
  465. key={r.id}
  466. href={`/store/${r.id}`}
  467. className='group block rounded-lg border border-neutral-200 dark:border-neutral-800 overflow-hidden hover:shadow-lg transition-shadow bg-white dark:bg-neutral-900'
  468. >
  469. <div className='aspect-square bg-neutral-100 dark:bg-neutral-800 overflow-hidden flex items-center justify-center'>
  470. {r.thumbnail ? (
  471. // eslint-disable-next-line @next/next/no-img-element
  472. <img src={r.thumbnail} alt={r.name} className='w-full h-full object-scale-down max-w-[calc(100%/1.6)] max-h-[calc(100%/1.6)] group-hover:scale-105 transition-transform duration-300' />
  473. ) : (
  474. <span className='text-neutral-400 text-xs'>이미지 없음</span>
  475. )}
  476. </div>
  477. <div className='p-3'>
  478. <div className='flex items-center gap-1.5 mb-1.5'>
  479. <TypeBadge type={r.type} />
  480. </div>
  481. <div className='text-sm font-semibold mb-1.5 line-clamp-2 min-h-[2.5rem]'>{r.name}</div>
  482. <div className='text-lg font-extrabold text-red-600 dark:text-red-400'>{r.discountType !== 0 && r.salePrice !== r.price ? formatPrice(r.salePrice) : formatPrice(r.price)}P{r.discountType !== 0 && r.salePrice !== r.price ? <span className='ml-1.5 text-xs line-through text-yellow-600 dark:text-yellow-400 font-normal'>{formatPrice(r.price)}P</span> : null}</div>
  483. </div>
  484. </Link>
  485. ))}
  486. </div>
  487. </div>
  488. )}
  489. {channelModalOpen && (
  490. <ChannelSelectModal
  491. requireChannel={product.requireDonationChannel}
  492. onClose={() => { setChannelModalOpen(false); setPendingPurchase(false); }}
  493. onSelect={(ch) => {
  494. setSelectedChannel(ch);
  495. setOptedOut(false);
  496. addRecent(ch);
  497. setChannelModalOpen(false);
  498. if (pendingPurchase) {
  499. setPendingPurchase(false);
  500. proceedPurchase(ch);
  501. }
  502. }}
  503. onSkip={() => {
  504. setSelectedChannel(null);
  505. setOptedOut(true);
  506. setChannelModalOpen(false);
  507. if (pendingPurchase) {
  508. setPendingPurchase(false);
  509. proceedPurchase(null);
  510. }
  511. }}
  512. />
  513. )}
  514. </div>
  515. );
  516. }
  517. function ChannelSelectModal({ onClose, onSelect, onSkip, requireChannel }: {
  518. onClose: () => void;
  519. onSelect: (ch: ChannelSearchRow) => void;
  520. onSkip: () => void;
  521. requireChannel: boolean;
  522. }) {
  523. const [keyword, setKeyword] = useState('');
  524. const [recent, setRecent] = useState<ChannelSearchRow[]>([]);
  525. const [randoms, setRandoms] = useState<ChannelSearchRow[]>([]);
  526. const [searchResults, setSearchResults] = useState<ChannelSearchRow[]>([]);
  527. const [loading, setLoading] = useState(true);
  528. const [initialized, setInitialized] = useState(false);
  529. // 모달 mount: localStorage 의 최근 채널을 서버에 검증(stale 자동 제거) 후 표시
  530. useEffect(() => {
  531. let cancelled = false;
  532. (async () => {
  533. const cached = loadRecent();
  534. let validated: ChannelSearchRow[] = [];
  535. if (cached.length > 0) {
  536. const ids = cached.map(c => c.id).join(',');
  537. const res = await fetchApi<{ list: ChannelSearchRow[] }>(`/api/store/channels/by-ids?ids=${ids}`, { silent: true });
  538. if (cancelled) {
  539. return;
  540. }
  541. if (res.success && res.data) {
  542. // 서버 응답에 포함된 ID 만 유지하고 캐시 순서(최신순) 보존. 서버 최신 정보로 덮어쓰기.
  543. const fresh = new Map(res.data.list.map(c => [c.id, c]));
  544. validated = cached.flatMap(c => {
  545. const f = fresh.get(c.id);
  546. return f ? [f] : [];
  547. });
  548. }
  549. // 검증 결과로 localStorage 동기화 (stale 제거 + 최신 정보 반영)
  550. saveRecent(validated);
  551. }
  552. if (!cancelled) {
  553. setRecent(validated);
  554. setInitialized(true);
  555. }
  556. })();
  557. return () => { cancelled = true; };
  558. }, []);
  559. // 검색어 변화에 따른 fetch (debounce 300ms). 검색어 없을 때는 random 으로 부족분 보충.
  560. useEffect(() => {
  561. if (!initialized) {
  562. return;
  563. }
  564. const handle = setTimeout(async () => {
  565. setLoading(true);
  566. // 검색어 있을 때: 검색 API 만 호출. recent 무시.
  567. if (keyword.trim()) {
  568. const params = new URLSearchParams();
  569. params.set('keyword', keyword.trim());
  570. params.set('page', '1');
  571. params.set('perPage', '5');
  572. const res = await fetchApi<ChannelSearchResponse>(`/api/store/channels/search?${params.toString()}`, { silent: true });
  573. if (res.success && res.data) {
  574. setSearchResults(res.data.list);
  575. }
  576. else {
  577. setSearchResults([]);
  578. }
  579. setLoading(false);
  580. return;
  581. }
  582. // 검색어 없을 때: recent 5개면 random 호출 안 함. 부족 시 (RECENT_MAX - recent.length) 만큼 random 으로 보충.
  583. const need = RECENT_MAX - recent.length;
  584. if (need <= 0) {
  585. setRandoms([]);
  586. setLoading(false);
  587. return;
  588. }
  589. const params = new URLSearchParams();
  590. params.set('random', 'true');
  591. params.set('page', '1');
  592. // recent 와 중복 가능성 대비해 여유 있게 가져와서 중복 제거
  593. params.set('perPage', String(need + recent.length));
  594. const res = await fetchApi<ChannelSearchResponse>(`/api/store/channels/search?${params.toString()}`, { silent: true });
  595. if (res.success && res.data) {
  596. const recentIDs = new Set(recent.map(r => r.id));
  597. const filtered = res.data.list.filter(c => !recentIDs.has(c.id)).slice(0, need);
  598. setRandoms(filtered);
  599. }
  600. else {
  601. setRandoms([]);
  602. }
  603. setLoading(false);
  604. }, 300);
  605. return () => { clearTimeout(handle); };
  606. }, [keyword, initialized, recent]);
  607. const handleRemoveRecent = (id: number) => {
  608. removeRecent(id);
  609. setRecent(prev => prev.filter(c => c.id !== id));
  610. };
  611. // 표시할 리스트: 검색어 있으면 검색 결과만, 없으면 recent + randoms.
  612. const displayList: { row: ChannelSearchRow; isRecent: boolean }[] = keyword.trim()
  613. ? searchResults.map(row => ({ row, isRecent: false }))
  614. : [
  615. ...recent.map(row => ({ row, isRecent: true })),
  616. ...randoms.map(row => ({ row, isRecent: false }))
  617. ];
  618. return (
  619. <div
  620. className='fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4'
  621. onClick={onClose}
  622. >
  623. <div
  624. className='bg-white dark:bg-neutral-900 rounded-lg w-full max-w-md max-h-[80vh] flex flex-col'
  625. onClick={(e) => { e.stopPropagation(); }}
  626. >
  627. <div className='p-4 border-b border-neutral-200 dark:border-neutral-800'>
  628. <div className='flex justify-between items-center mb-3'>
  629. <h2 className='font-bold'>후원 채널 선택</h2>
  630. <button type='button' onClick={onClose} className='text-neutral-500 hover:text-neutral-700'>✕</button>
  631. </div>
  632. <input
  633. type='search'
  634. value={keyword}
  635. onChange={(e) => { setKeyword(e.target.value); }}
  636. placeholder='채널명, 핸들, 후원 코드 검색'
  637. className='w-full border border-neutral-300 dark:border-neutral-700 rounded px-3 py-2 text-sm bg-white dark:bg-neutral-900'
  638. maxLength={100}
  639. />
  640. {!keyword.trim() ? (
  641. <p className='text-[11px] text-neutral-400 mt-1'>* 최근 후원 채널 후 무작위 순서로 표시됩니다.</p>
  642. ) : (
  643. <p className='text-[11px] text-neutral-400 mt-1'>* 후원 코드는 정확히 입력해야 합니다.</p>
  644. )}
  645. </div>
  646. <div className='flex-1 overflow-y-auto p-2'>
  647. {loading ? (
  648. <Loading type={2} />
  649. ) : displayList.length === 0 ? (
  650. <div className='text-center py-8 text-neutral-500 text-sm'>채널이 없습니다.</div>
  651. ) : (
  652. displayList.map(({ row: ch, isRecent }) => (
  653. <div
  654. key={ch.id}
  655. className='w-full flex items-center gap-3 p-3 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded'
  656. >
  657. <button
  658. type='button'
  659. onClick={() => { onSelect(ch); }}
  660. className='flex-1 min-w-0 text-left flex items-center gap-3'
  661. >
  662. {ch.thumbnailUrl ? (
  663. // eslint-disable-next-line @next/next/no-img-element
  664. <img src={ch.thumbnailUrl} alt='' className='w-10 h-10 rounded-full object-cover shrink-0' />
  665. ) : (
  666. <div className='w-10 h-10 rounded-full bg-neutral-300 dark:bg-neutral-700 shrink-0' />
  667. )}
  668. <div className='flex-1 min-w-0'>
  669. <div className='font-semibold truncate flex items-center gap-1.5'>
  670. <span className='truncate'>{ch.name}</span>
  671. {ch.isVerified && (
  672. <span className='shrink-0 text-[10px] bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 rounded px-1'>✓</span>
  673. )}
  674. {ch.donationCode && (
  675. <span
  676. className='shrink-0 font-mono text-[11px] px-2 py-[3px] rounded-md tracking-[0.15em] uppercase
  677. bg-neutral-100 dark:bg-neutral-800
  678. text-neutral-700 dark:text-neutral-200
  679. border border-neutral-200/60 dark:border-neutral-700/60
  680. shadow-[inset_0_1px_2px_rgba(0,0,0,0.18),inset_0_-1px_0_rgba(255,255,255,0.55)]
  681. dark:shadow-[inset_0_1px_3px_rgba(0,0,0,0.6),inset_0_-1px_0_rgba(255,255,255,0.04)]'
  682. >
  683. {ch.donationCode}
  684. </span>
  685. )}
  686. </div>
  687. {ch.handle && (
  688. <div className='text-xs text-neutral-500 truncate'>{ch.handle}</div>
  689. )}
  690. </div>
  691. </button>
  692. {isRecent && (
  693. <button
  694. type='button'
  695. onClick={(e) => {
  696. e.stopPropagation();
  697. handleRemoveRecent(ch.id);
  698. }}
  699. className='shrink-0 size-6 inline-flex items-center justify-center rounded-full
  700. text-neutral-400 hover:text-neutral-700 hover:bg-neutral-200
  701. dark:hover:text-neutral-200 dark:hover:bg-neutral-700'
  702. aria-label='최근 목록에서 제거'
  703. title='최근 목록에서 제거'
  704. >
  705. </button>
  706. )}
  707. </div>
  708. ))
  709. )}
  710. </div>
  711. {requireChannel ? (
  712. <div className='p-3 border-t border-neutral-200 dark:border-neutral-800 text-xs text-amber-600 dark:text-amber-400'>
  713. 후원 채널 선택이 필수인 상품입니다.
  714. </div>
  715. ) : (
  716. <div className='p-3 border-t border-neutral-200 dark:border-neutral-800'>
  717. <label className='flex items-center gap-2 text-sm cursor-pointer text-neutral-600 dark:text-neutral-300'>
  718. <input type='checkbox' onChange={(e) => { if (e.target.checked) { onSkip(); } }} className='size-4' />
  719. <span>선택 안함</span>
  720. </label>
  721. </div>
  722. )}
  723. </div>
  724. </div>
  725. );
  726. }